Skip to content

Read only when the channel does not auto-read - #2302

Merged
hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/skip-redundant-read-when-autoread-on
Aug 5, 2026
Merged

Read only when the channel does not auto-read#2302
hyperxpro merged 3 commits into
AsyncHttpClient:mainfrom
maygemdev:fix/skip-redundant-read-when-autoread-on

Conversation

@pavel-ptashyts

Copy link
Copy Markdown
Contributor

Problem

AsyncHttpClientHandler requested a read from both lifecycle callbacks:

public void channelActive(ChannelHandlerContext ctx)       { ctx.read(); }
public void channelReadComplete(ChannelHandlerContext ctx) { ctx.read(); }

Netty's HeadContext already calls Channel#read() immediately after firing
either event whenever autoRead is on. AsyncHttpClient never clears autoRead
(no ChannelOption.AUTO_READ anywhere in the codebase) and Netty defaults it to
on, so that was the path for every connection: each read cycle traversed the
outbound pipeline and reached doBeginRead twice instead of once. The cost is
per read cycle, so it scales with how many reads a response takes.

Change

Drive the read from the handler only when autoRead is off.

That is also a fix in its own right: a caller who disabled autoRead through
setChannelOption previously had the setting silently defeated by these two
unconditional reads, since the handler kept requesting reads regardless.

Verification of the Netty behaviour

Checked against netty-codec-http2 / netty-transport 4.2.16.Final, the
version this project builds against, rather than assumed:

  • DefaultChannelPipeline$HeadContext.channelActive and .channelReadComplete
    both call readIfIsAutoRead(), which is
    if (channel.config().isAutoRead()) channel.read();
  • DefaultChannelConfig's constructor initialises autoRead to 1, so on.
  • HTTP/2 stream channels inherit both behaviours:
    AbstractHttp2StreamChannel$2 extends DefaultChannelPipeline, and
    Http2StreamChannelConfig extends DefaultChannelConfig without overriding
    isAutoRead.

The last point matters because this is the shared base class of HttpHandler,
WebSocketHandler and Http2Handler, and neither of the two callbacks is
overridden by any of them, so the change applies to HTTP/1.1, WebSocket and
HTTP/2 stream channels alike.

Verification of the build

mvnw clean verify - BUILD SUCCESS, 1371 tests, 0 failures, 0 errors,
19 skipped. Error Prone, NullAway and Revapi all clean.

The suites covering the paths most exposed to a change in read behaviour are
green: 168 HTTP/2 tests (BasicHttp2Test, Http2MultiplexBugRegressionTest,
Http2StreamingBodyFlowControlTest, Http2StreamOrphanRegressionTest,
Http2ConformanceRegressionTest and the rest) and 36 WebSocket tests
(TextMessageTest, ByteMessageTest, CloseCodeReasonMessageTest,
WebSocketWriteFutureTest, ws.ProxyTunnellingTest).

Caveat on the testing gate: AGENTS.md requires the build to run on JDK 11 and
no JDK 11 is installed on this machine, so it was run on JDK 17 (also in the
CI matrix). The JDK 11 leg of CI on this PR is the real gate.

No public API change. Same review pass as #2300 and #2301.

Claude Code on behalf of @pavel-ptashyts

🤖 Generated with Claude Code

AsyncHttpClientHandler requested a read from both channelActive and
channelReadComplete. Netty's HeadContext already calls Channel#read()
right after firing either event whenever autoRead is on, so every read
cycle traversed the outbound pipeline and reached doBeginRead twice
instead of once. AsyncHttpClient never clears autoRead and Netty
defaults it to on, so that was the normal path for every connection,
and the waste grew with the number of read cycles a response took.

Drive the read from here only when autoRead is off. That keeps working
for a caller who disables autoRead through a channel option, which is
also a fix in its own right: such a caller previously had the setting
silently defeated by these two unconditional reads.

Verified against netty 4.2.16.Final rather than assumed. HeadContext
calls readIfIsAutoRead() from both channelActive and
channelReadComplete, DefaultChannelConfig initialises autoRead to on,
and HTTP/2 stream channels share both behaviours: their pipeline
extends DefaultChannelPipeline and Http2StreamChannelConfig extends
DefaultChannelConfig without overriding isAutoRead. The change
therefore holds for all three subclasses, HTTP/1.1, WebSocket and
HTTP/2.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Override
public void channelActive(ChannelHandlerContext ctx) {
ctx.read();
readIfNotAutoRead(ctx);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth noting for anyone else reading this, HeadContext calls readIfIsAutoRead after fireChannelActive returns, not as part of the propagation. So it does not matter that this handler never forwards the event, the read still happens. Same for channelReadComplete below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, and that ordering is what makes this safe. The javadoc says it now, so the next reader does not have to wonder whether the handler swallowing the event matters.

@Override
public void channelReadComplete(ChannelHandlerContext ctx) {
ctx.read();
readIfNotAutoRead(ctx);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I traced the HTTP/2 side since that is where dropping one of two reads could have bitten us. Inside the read loop the second read only flipped readStatus from IN_PROGRESS to REQUESTED and resetReadStatus put it straight back, so the stream channel ends in the same state either way. Outside the loop the status is already reset to IDLE before the event is fired, so the HeadContext read does the full drain on its own. No read is lost, and we lose a layer of re-entrancy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for tracing that. The pipeline and config side I could confirm from the bytecode, but the readStatus transitions inside the read loop were the part I could not rule out that way.

* Requests the next read only when the channel will not do it by itself. Netty's HeadContext already
* calls Channel#read() after firing channelActive and channelReadComplete whenever autoRead is on, so
* reading here as well only repeated the outbound pipeline traversal and doBeginRead. AsyncHttpClient
* never clears autoRead, which defaults to on, so this is the usual path; a caller that turns it off

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sentence is a claim about the rest of the codebase and it goes stale the day someone puts AUTO_READ in a config. Can we keep the comment to the mechanism and drop the survey of current usage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped. Fair point that it dates the comment for no benefit; the mechanism holds whether or not anything in the codebase sets AUTO_READ.

* through a channel option still needs reads to be driven from here, which is why the call is kept
* rather than dropped.
*/
private static void readIfNotAutoRead(ChannelHandlerContext ctx) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Netty spells this readIfNeeded in SslHandler, and Http2ConnectionHandler.channelReadComplete0 does the same guard inline. Matching the name and pointing at one of those in the javadoc would make it obvious this is the house style and not something we invented.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to readIfNeeded, and the javadoc now points at SslHandler and at the inline version in Http2ConnectionHandler.channelReadComplete0. Better to match what Netty already does in two places than to invent a name for it.

Review feedback on AsyncHttpClient#2302. Netty spells this guard readIfNeeded in
SslHandler and inlines the same test in
Http2ConnectionHandler.channelReadComplete0, so take that name and
point at both. It makes clear the shape is Netty's rather than
something invented here.

Drop the sentence stating that AsyncHttpClient never clears autoRead.
It is a survey of current usage rather than of the mechanism, and it
goes stale the day a config grows an AUTO_READ option.

Claude Code on behalf of Pavel Ptashyts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@hyperxpro
hyperxpro merged commit db6550f into AsyncHttpClient:main Aug 5, 2026
13 checks passed
hyperxpro added a commit that referenced this pull request Aug 5, 2026
## Problem

For the default handler (`executeRequest(request)` ->
`AsyncCompletionHandlerBase`
-> `Response`) the body is copied three times:

1. `HttpHandler.handleChunk` -> `EagerResponseBodyPart` copies each
chunk out of
the network buffer into a heap `byte[]` (needed: `channelRead` releases
the
   message afterwards);
2. `NettyResponse.getResponseBodyAsByteBuffer` concatenates every part
into a
   freshly allocated array;
3. `getResponseBody(charset)` decodes that array.

Step 2 is pure waste when there is only one part, which is the case for
any body
that lands in a single socket read: it copies a single part into a new
array with
nothing to concatenate it with.

## Change

`getResponseBody(Charset)` decodes straight from the part when there is
exactly
one of them. The array does not escape the method, so the part's own
array can be
decoded in place.

Several parts are still concatenated before decoding, never decoded one
at a
time, because a multi-byte character can straddle a part boundary.

`getResponseBodyAsBytes` and `getResponseBodyAsByteBuffer` are
deliberately left
untouched: they hand the array to the caller, so they keep making a
defensive
copy rather than expose a part's own array. There is no aliasing change
anywhere
in this PR.

## Measurements

Rough probe on JDK 17 over a single-part ASCII body,
concatenate-then-decode
versus decode-in-place. Not JMH, so read the shape rather than the
digits:

| body | before | after |
|------|--------|-------|
| 512 B | 496 ns | 47 ns |
| 4 KB | 2277 ns | 373 ns |
| 16 KB | 2963 ns | 1450 ns |
| 128 KB | 24913 ns | 12248 ns |

Plus one fewer whole-body allocation per response. The percentages look
large
partly because a pure-ASCII body decodes through a JDK intrinsic, which
makes the
removed copy a big share of what is left.

## Tests

Two added to `NettyAsyncResponseTest`:

* `testGetResponseBodyDecodesOnePartAndSplitPartsIdentically` splits the
two-byte
UTF-8 encoding of U+00E9 across two parts and asserts one-part and
split-part
bodies decode alike. This pins the constraint the comment states: it
fails if
  anyone later makes the multi-part path decode part by part.
* `testGetResponseBodyAsBytesDoesNotShareTheBodyPartArray` pins that
`getResponseBodyAsBytes` still returns a fresh array and never the
part's own.

The body bytes are built as an explicit `byte[]` rather than a string
literal to
keep the source ASCII per `AGENTS.md`.

## Verification

`mvnw clean verify` - BUILD SUCCESS, 1373 tests (1371 before, plus these
two),
0 failures, 0 errors, 19 skipped. Error Prone, NullAway and Revapi
clean.
`LargeResponseTest`, `NoNullResponseTest`,
`BodyDeferringAsyncHandlerTest` and
`RedirectBodyTest`, which exercise the multi-part path, are green.

Caveat on the testing gate: `AGENTS.md` requires the build to run on JDK
11 and
no JDK 11 is installed on this machine, so it was run on **JDK 17**
(also in the
CI matrix). The JDK 11 leg of CI on this PR is the real gate.

## Not in scope

The multi-part case still concatenates. A
`CompositeByteBuf.toString(charset)`
variant measured faster there (Netty decodes a multi-component buffer
through a
recycled, un-zeroed thread-local array instead of a fresh `byte[]`), but
it
regressed at high part counts in the same probe, so it needs proper
benchmarking
before it becomes a change. Removing copy 1 would mean retaining network
buffers
and giving `Response` a lifecycle, which is public API and wants a
design
discussion first.

No public API change here. Same review pass as #2300, #2301 and #2302.

Claude Code on behalf of @pavel-ptashyts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Aayush Atharva <24762260+hyperxpro@users.noreply.github.com>
hyperxpro added a commit that referenced this pull request Aug 5, 2026
Motivation:

#2302 stopped AsyncHttpClientHandler requesting a read that Netty's
HeadContext already drives when autoRead is on.
Nothing in the suite catches that duplicate coming back, since the
connection works correctly either way

Modification:

Add AsyncHttpClientHandlerReadTest, an EmbeddedChannel test that counts
reads at an outbound handler placed in front of the handler under test,
so it sees both entry points.
Covers channelActive and channelReadComplete with autoRead on, and
channelReadComplete with it off.

Result:

One read per cycle with autoRead on, reads still driven with it off;
without #2302 the count is 2.
@pavel-ptashyts
pavel-ptashyts deleted the fix/skip-redundant-read-when-autoread-on branch August 6, 2026 07:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants